昨天的文章中,我們認識了如何用 IO 這容器延後副作用的執行,掌握了 IO 的核心思想後,我們就可以將同樣的原則應用到一個更複雜、更常見的領域:非同步操作。Task 就是 IO 在非同步世界的巒生兄弟,它可以為我們解決 JavaScript 中麻煩的非同步問題,今天就來看看 Task 是什麼吧~
在現代 JavaScript 中,非同步操作無所不在,傳統上我們用回呼函式 (callback) 來處理,但很快就陷入「callback hell」的維護地獄,而為了解決這問題,Promise 就出現了! 使用 Promise 可以解決 callback hell 的問題,但 Promise 有一個缺點:Promise 是急切的 (Eager Evaluation)。
一旦建立 Promise,內部的非同步操作就會立即啟動,不管你是否真的需要,或何時才需要它。這在 FP 的觀點下是一種失去控制的狀態——我們無法先「描述」一個 API 呼叫,等到準備好再啟動;就像火箭圖紙一畫好,引擎就自動點火發射,讓我們失去將它與其他流程組合的機會。
而 Task 就是為了解決這個問題,可以將 Task 視為一種「純函式版的 Promise」,也可稱作 Lazy Promise:像 Promise 一樣代表一個未來的結果,但不會在建立時就急著執行。我們可以拿到一個 Task,透過 map
等方式先描述並組合一系列非同步工作,直到最後主動觸發時才真的執行。這種惰性模型讓非同步流程更易於組合、避免回呼地獄,同時給我們對副作用執行時機的完整掌控。
舉例來說,new Promise(() => console.log('runs now'))
在宣告當下就會印出 'runs now'
;但 Task 在建立時不會立即執行,必須用 task()
或經由 match → ()
觸發。
若對 Promise 和 Task 兩者的比較有興趣的,可再參考 Difference between a Promise and a Task、Comparison to Promises。
如果說 IO 是用來描述同步副作用的「藍圖」,那麼 Task 就是專門處理非同步副作用的「藍圖」。它和 IO 一樣是惰性的容器,但包裹的不是一個簡單的 () => value
,而是一個需要時間才能完成,並且可能成功或失敗的計算。
具體來說,Task 封裝的是一個接受 (reject, resolve)
兩個回呼函式的函式,類似這樣:(reject, resolve) => void
。因此,我們可以把 Task 理解為非同步版的 IO:它也是 Functor,擁有 map
等方法,但它所描述的是「未來才會得到的值或錯誤」。建立 Task 時並不會馬上執行,只有在我們明確呼叫時,Task 內的計算才會開始,並透過 reject
或 resolve
將結果交回,讓我們能以純函數的方式組合與控制非同步流程。
因為 Task 內部實作比較複雜,我們直接用現成函式庫提供的 Task 來看看如何使用吧~
原本《mostly-adequate-guide》使用的是 Folktale 函式庫的,但因為此 repo 最近比較少維護、且已經 archived,這裡就直接使用 fp-ts 函式庫的 Task 和 TaskEither。Task 和 TaskEither 主要差異在於,Task 用來描述只有成功情況的非同步任務,而 TaskEither 會描述可能成功、也可能失敗的非同步任務,可以將 TaskEither 視為一個隱含結合 Either 特性的容器。
不過如果覺得加入 TypeScript 會太複雜,也可考慮用 Folktale 函式庫的 Task 先了解一下大概運作,核心概念應該差異不大。
以下我們用 fp-ts 的 TaskEither 來讀取檔案然後印出檔案中的第一行文字:
(完整可運作程式碼可參考此連結,感謝 olddunk 大大在此篇文章提供了起始專案,直接拿來用 XD)
import * as TE from 'fp-ts/TaskEither';
import * as T from 'fp-ts/Task';
import * as fs from 'fs/promises';
// 輔助函式
const compose =
(...fns: Array<(a: any) => any>) =>
(x: any) =>
fns.reduceRight((v, f) => f(v), x);
const split = (sep: string) => (s: string) => s.split(sep);
const headString = (xs: string[]) => xs[0];
// ----- 主要程式 -----
/** readFile :: string -> TaskEither<Error, string> */
const readFile = (filename: string): TE.TaskEither<Error, string> =>
TE.tryCatch(
() => fs.readFile(filename, 'utf-8'),
(e) => (e instanceof Error ? e : new Error(String(e)))
);
// 這等同於 readFile('metaphor.txt').map(split('\n')).map(head)
const firstLineTE: TE.TaskEither<Error, string> = compose(
TE.map(headString),
TE.map(split('\n')),
readFile
)('metaphor.txt');
console.log('TaskEither 已建立,但尚未執行');
// 執行:match 之後會得到 T.Task<void>,再呼叫它觸發
const run = compose(
(task: T.Task<void>) => task(), // 觸發 Task
TE.match(
(err: Error) => {
console.error('讀檔失敗:', err.message);
},
(line: string) => {
console.log('第一行內容:', line);
}
)
);
run(firstLineTE);
來逐步拆解一下程式碼在做什麼吧~
整段程式建立了兩條惰性管線:
const firstLineTE = compose(
TE.map(headString), // Right<string[]> → Right<string>
TE.map(split('\n')), // Right<string> → Right<string[]>
readFile // string → TaskEither<Error, string>
)('metaphor.txt'); // ← 這裡只是在「描述」,還沒動硬碟
const run = compose(
(task: T.Task<void>) => task(), // 真正觸發非同步
TE.match( // 將 Left/Right 收斂成 side-effect
(err: Error) => { console.error('讀檔失敗:', err.message); },
(line: string) => { console.log('第一行內容:', line); }
)
);
readFile
會回什麼?const readFile = (filename: string): TE.TaskEither<Error, string> =>
TE.tryCatch(
() => fs.readFile(filename, 'utf-8'), // ← 只在「執行時」才會呼叫
e => e instanceof Error ? e : new Error(String(e))
);
TaskEither<Error, string>
,想像成「尚未執行的非同步描述」,成功是 Right<string>
,失敗是 Left<Error>
。TE.tryCatch
會把 Promise 的拒絕/例外包成 Left(Error)
,成功包成 Right(value)
。這裡只定義了讀檔的方式,還沒有真的讀檔。
TE.map
做了什麼?TE.map
和 Either 運作概念相同,只會在 Right
(成功值) 上套用轉換函式;如果是 Left
(錯誤),它會原封不動地往後傳(不會觸發轉換函式)。
因此 TE.map(split('\n'))
會把 Right<string>
變 Right<string[]>
;TE.map(headString)
會把 Right<string[]>
變 Right<string>
firstLineTE
只是「描述」,還沒執行const firstLineTE = compose(
TE.map(headString),
TE.map(split('\n')),
readFile
)('metaphor.txt'); // ('metaphor.txt') 是給 readFile 的參數
console.log('TaskEither 已建立,但尚未執行');
在 console.log('TaskEither 已建立,但尚未執行');
之前,都還沒去實際硬碟取出 'metaphor.txt'
這檔案。
成功路線的型別演進如下:
readFile('...')
→ TaskEither<Error, string>
TE.map(split('\n'))
→ TaskEither<Error, string[]>
TE.map(headString)
→ TaskEither<Error, string>
run
在「需要時執行」const run = compose(
(task: T.Task<void>) => task(), // 這一步才真正觸發非同步
TE.match(
(err: Error) => { console.error('讀檔失敗:', err.message); },
(line: string) => { console.log('第一行內容:', line); }
)
);
run(firstLineTE);
TE.match
會收兩個參數,分別為 onLeft
和 onRight
,match
函式的作用是把 TaskEither<E, A>
轉成一個 Task<void>
。如果後續是 Left
→ 執行 onLeft
,如果後續是 Right
→ 執行 onRight
(task) => task()
才真的觸發非同步流程(真的呼叫到底層的 fs.readFile
)。
成功(檔案存在且非空)
run(firstLineTE)
TE.match
產生一個 Task<void>
task()
→ 進入 TE.tryCatch
→ fs.readFile('metaphor.txt', 'utf-8')
Right(content)
TE.map(split('\n'))
→ Right(lines: string[])
TE.map(headString)
→ Right(firstLine: string)
TE.match
的 Right
分支執行 → console.log('第一行內容:', firstLine)
失敗(例如檔案不存在)
1–3 步同上
4. 發生錯誤 → 包成 Left(Error)
5–6. 全部 TE.map(...)
跳過(Left
不會被映射)
7. TE.match
的 Left 分支執行 → console.error('讀檔失敗:', err.message)
另外補充一下,fp-ts 的 match
與其他函式庫的 .fork
的關係,在一些函式庫(例如 Folktale)裡,Task 的執行通常透過 .fork(rejectFn, resolveFn)
來觸發:
task.fork(
err => console.error('失敗', err),
data => console.log('成功', data)
);
這裡的 .fork
會同時:
rejectFn
,把成功值丟進 resolveFn
在 fp-ts 中,雖然沒有 .fork
方法,但可以用 TE.match(onLeft, onRight)
搭配呼叫 ()
達到相同效果:
TE.match(
(err: Error) => console.error('失敗', err.message),
(line: string) => console.log('成功', line)
)(firstLineTE)(); // 最後的 () 觸發 Task
兩者本質相同:都是在程式邊界,把惰性的描述真正執行起來,並分流錯誤與成功的處理邏輯。
我們一樣可以把純值放入 Task,範例如下。
// 也可以直接建立一個純值的 Task
const numberTask: T.Task<number> = T.of(3);
// 使用 map 對容器內的值做轉換
const incremented: T.Task<number> = T.map((x: number) => x + 1)(numberTask);
// 執行
incremented().then(console.log); // 4
readFile
、getJSON
等非同步操作,不再需要額外包 IO 容器.map()
就像在「時間膠囊」放入待辦清單,是一種「技術性拖延」看越多範例越了解這些工具的運作流程! 再看一個範例吧,這範例會直接使用 fp-ts 提供的工具。
這範例的情境是,我們有一份 db.json
設定檔,要做以下三件事:
db.json
設定檔案補充下,這裡只是稍微模擬一個資料庫連線情境,不是真的有建 db 然後連線下 query,這裡只是簡單模擬此情境下可以如何用 FP 工具實作。
要做到這些事,我們需要 Task、Either 與 IO:
TaskEither<Error, A>
:處理「可能失敗的非同步讀檔任務」──讀不到檔案、I/O 例外情況Either<Error, A>
:處理「可能失敗的同步驗證任務」──JSON 解析錯、欄位缺漏IO<A>
:封裝「同步副作用的描述」──真正建立連線等到需要時才執行在實作程式之前,因為這裡統一用 fp-ts 的工具來撰寫,而 fp-ts 函式庫內是沒有我們之前介紹過的 compose
function 的,fp-ts 提供的工具是 pipe
和 flow
。pipe
和我們之前自己實作的版本幾乎相同,用來把「值」依序送進多個函數。而 flow
則可以理解成是「左到右版本的 compose
」。
以下稍微說明 flow
和 pipe
、compose
的差異。
pipe
import { pipe } from 'fp-ts/function';
// 從左到右,第一個先傳入 data
const result = pipe(
3, // 先有初始要處理的 data
(x: number) => x + 1,
(y: number) => y * 2
);
console.log(result); // 8
flow
import { flow } from 'fp-ts/function';
// 從左到右,但第一個傳入的是函式,最後要使用時才傳入 data
const f = flow(
(x: number) => x + 1,
(y: number) => y * 2
);
console.log(f(3)); // 8
compose
f(g(x))
flow
取代)const compose =
<A, B, C>(f: (b: B) => C, g: (a: A) => B) =>
(a: A): C =>
f(g(a));
const double = (x: number) => x * 2;
const inc = (x: number) => x + 1;
const f = compose(double, inc); // f(x) = double(inc(x))
console.log(f(3)); // 8
有了背景知識,也知道這應用的情境後,來看程式實作吧~
(完整可運作程式碼可參考此連結,再次感謝 olddunk 大大在此篇文章提供了起始專案🙏)
首先我們要先建立一個 db.json
檔案來模擬 db
的設定資訊(實際開發不會這樣直接寫帳號密碼,這只是個模擬範例)
{
"uname": "monica",
"pass": "pass123",
"host": "localhost",
"db": "mydb"
}
接著寫一個簡單的程式碼模擬資料庫的連線和查詢:
// src/pg.ts
import * as IO from 'fp-ts/IO';
export type Url = string;
export interface DbConnection {
url: Url;
}
export interface ResultSet {
rows: Array<{ id: number; name: string }>;
}
// Postgres.connect :: Url -> IO DbConnection
export const Postgres = {
connect:
(url: Url): IO.IO<DbConnection> =>
() => {
// 這裡就當作連上了(真實世界才會去呼叫 driver)
console.log('[IO] Establishing DB connection to:', url);
return { url };
},
// runQuery :: DbConnection -> ResultSet
runQuery: (db: DbConnection): ResultSet => {
// 純函式,模擬回傳查詢結果
return {
rows: [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
],
};
},
};
再來就是我們的主要程式,我們透過 TaskEither
來讀檔、Either
驗證,然後用 IO
延遲連線和查詢。
TaskEither
讀檔第一步是定義讀取檔案的函式,我們需要從檔案系統讀取設定檔,而這是非同步任務,且可能失敗,所以用 TaskEither<Error, string>
,失敗就回傳 Error
,成功就回傳字串內容。
// readFile :: String -> TaskEither<Error, string>
const readFileTE = (filename: string): TE.TaskEither<Error, string> =>
TE.tryCatch(() => fs.readFile(filename, 'utf8'), E.toError);
Either
驗證設定並組合 URL第二步是驗證 db
設定檔中的內容,並組合 URL,因為驗證可能會失敗,所以使用 Either<Error, Url>
來表示驗證結果:
// dbUrl :: Config -> Either<Error, Url>
const dbUrl = (cfg: Config): E.Either<Error, Url> => {
const { uname, pass, host, db } = cfg;
return uname && pass && host && db
? E.right(`db:pg://${uname}:${pass}@${host}:5432/${db}`)
: E.left(new Error('Invalid config! (uname/pass/host/db required)'));
};
IO
包裝副作用如果設定合法,就可以產生一個「延遲執行」的資料庫連線。這裡我們用 IO<DbConnection>
,它不會立刻執行,而是等我們呼叫時才真正建立連線,所以 connectDb
是一個「描述預計如何執行資料庫連線」的函式,但實際上它還沒有執行、因此目前為止還沒有真的建立資料庫連線。
// connectDb :: Config -> Either<Error, (IO DbConnection)>
const connectDb = flow(
dbUrl, // Config -> Either<Error, Url>
E.map(Postgres.connect) // map :: (Url -> IO<DbConnection>) -> Either<Error, IO<DbConnection>>
);
現在我們要把前面幾個步驟接起來:
readFileTE
讀檔(TaskEither
)JSON.parse
把字串轉成 ConfigconnectDb
驗證並建立 IO 連線這樣就會得到一個 TaskEither<Error, Either<Error, IO<DbConnection>>>
。
// getConfig :: Filename -> TaskEither<Error, Either<Error, IO<DbConnection>>>
const getConfig = flow(
readFileTE, // String -> TaskEither<Error, string>
TE.map(
// map over TaskEither's Right(string)
flow(
(s: string) => JSON.parse(s) as Config, // string -> Config
connectDb // Config -> Either<Error, IO<DbConnection>>
)
)
);
到第四步驟為止,我們都還在定義「預計如何操作」的函式,還沒真的呼叫執行、建立資料庫連線。現在我們要定義一個 main
函式來觸發副作用。
這個函式會有內外層的 Either:
IO<DbConnection>
而 IO.map(Postgres.runQuery)
會把查詢映射進 IO,直到最後呼叫才真正執行。
const main = async () => {
const file = 'db.json'; // 改成 wrongdb.json 可測試錯誤
const result = await getConfig(file)(); // 執行 Task
// 內層 Either:處理設定驗證
const onEither = E.match<Error, IO.IO<DbConnection>, void>(
(cfgErr) => {
console.error('設定錯誤:', cfgErr.message);
},
(ioConn) => {
const ioResult: IO.IO<ResultSet> = IO.map(Postgres.runQuery)(ioConn);
const rs = ioResult(); // 直到這裡才真正連線 + 查詢
console.log('查詢結果:', rs);
}
);
// 外層 Either:處理讀檔
E.match<Error, E.Either<Error, IO.IO<DbConnection>>, void>(
(readErr) => {
console.error('讀檔失敗:', readErr.message);
},
(cfgEither) => {
onEither(cfgEither);
}
)(result);
};
main().catch((e) => {
console.error('未預期錯誤:', E.toError(e).message);
});
我們把副作用統一放在「應用程式邊界」觸發(例如 main()
、框架的 entry / effect layer),核心邏輯保持純粹。在呼叫 main
函式之前,都不會產生任何副作用,我們延遲了資料庫連線的操作,只在需要時執行,來降低副作用帶來的不可控性。
如果還記得之前提過的程式分為 Action、Calculation 和 Data,在 main
函式之前,我們盡量利用 Task 將副作用行為包裝成純粹的 Calculation,減少程式中 Action 的比例,這樣只有 main
是會產生副作用的函式。
以下是完整的程式實作。
import { promises as fs } from 'fs';
import { flow } from 'fp-ts/function'; // compose
import * as E from 'fp-ts/Either'; // Either
import * as TE from 'fp-ts/TaskEither'; // TaskEither
import * as IO from 'fp-ts/IO'; // IO
import { Postgres, Url, DbConnection, ResultSet } from './pg';
type Config = {
uname?: string;
pass?: string;
host?: string;
db?: string;
};
// ---------- readFile :: String -> TaskEither<Error, string> ----------
const readFileTE = (filename: string): TE.TaskEither<Error, string> =>
TE.tryCatch(() => fs.readFile(filename, 'utf8'), E.toError);
// ---------- dbUrl :: Config -> Either<Error, Url> ----------
const dbUrl = (cfg: Config): E.Either<Error, Url> => {
const { uname, pass, host, db } = cfg;
return uname && pass && host && db
? E.right(`db:pg://${uname}:${pass}@${host}:5432/${db}`)
: E.left(new Error('Invalid config! (uname/pass/host/db required)'));
};
// ---------- connectDb :: Config -> Either<Error, (IO DbConnection)> ----------
const connectDb = flow(
dbUrl, // Config -> Either<Error, Url>
E.map(Postgres.connect) // map :: (Url -> IO<DbConnection>) -> Either<Error, IO<DbConnection>>
);
// ---------- getConfig :: Filename -> TaskEither<Error, Either<Error, IO<DbConnection>>> ----------
const getConfig = flow(
readFileTE, // String -> TaskEither<Error, string>
TE.map(
// map over TaskEither's Right(string)
flow(
(s: string) => JSON.parse(s) as Config, // string -> Config
connectDb // Config -> Either<Error, IO<DbConnection>>
)
)
);
// ---------- Impure calling code(觸發副作用) ----------
const main = async () => {
const file = 'db.json'; // 可試試把檔名改成 wrongdb.json 來看看錯誤路線
// getConfig(file) :: TaskEither<Error, Either<Error, IO<DbConnection>>>
const result = await getConfig(file)(); // 執行 Task,得到 Promise<Either<Error, Either<Error, IO<DbConnection>>>>
// 對應到:either(console.log, map(runQuery))
const onEither = E.match<Error, IO.IO<DbConnection>, void>(
(cfgErr) => {
// Left:設定錯誤
console.error('設定錯誤:', cfgErr.message);
},
(ioConn) => {
// Right(IO<DbConnection>):把 runQuery 映射進 IO
const ioResult: IO.IO<ResultSet> = IO.map(Postgres.runQuery)(ioConn);
// 直到這裡才真正執行 IO(建立連線 + 查詢)
const rs = ioResult();
console.log('查詢結果:', rs);
}
);
// 外層 Either:讀檔成功 → 裡面包 Either;讀檔失敗 → Left(Error)
E.match<Error, E.Either<Error, IO.IO<DbConnection>>, void>(
(readErr) => {
// readFile 失敗
console.error('讀檔失敗:', readErr.message);
},
(cfgEither) => {
// 讀檔成功 → 處理設定的 Either
onEither(cfgEither);
}
)(result);
};
main().catch((e) => {
console.error('未預期錯誤:', E.toError(e).message);
});
用幾個問題來總結今天和昨天的文章。
為了將不純的副作用(同步與非同步)封裝成純粹的、可組合的值。讓我們能將程式的核心邏輯(如何組合、轉換資料)與程式的邊界(何時、如何執行副作用)徹底分離,保護函式的純粹性與可測試性。
核心差異在於「惰性 (Laziness)」。
IO 與 Task 將「描述」和「執行」徹底分離,給予了我們對於「何時」、「何地」、以及「是否」要觸發副作用的完全控制權。這與立即執行的指令式程式碼不同。
() => value
的函式,這個函式描述了要執行的動作。它是一個 Functor,可以用 .map
來組合後續的純粹計算(reject, resolve) => void
的函式,描述了一個可能成功或失敗的非同步計算。它也是一個 Functor,可以用 .map
來操作未來的成功值。共同點:它們都是將「不純的動作」具象化為「純粹的數值」的工具,它們將不可控的 Action 轉為沒有副作用、單純的 Data 來傳遞,透過延遲執行來讓程式更有組合和控制的彈性。